Skip to content
created by Aha00aAha00a at 2026-08-21
last modified by Aha00aAha00a at 2026-09-15
revision: 8

Dev Database

Dev

스키마의 정본은 conf/evolutions/default/*.sql 이다. Play 가 시작할 때 적용한다.

데이터베이스는 공유 RDS 인스턴스 위의 스키마 하나다. 같은 인스턴스에 다른 프로젝트들이 산다 — 이 스키마 밖은 절대 건드리지 않는다.

1. 여러 문장 쓰기는 LocalTransaction 을 거친다

models.tables.LocalTransaction 은 블록을 감싸, 연결이 이미 트랜잭션 안이든 아니든 하나로 커밋되거나 하나로 롤백되게 한다. 대부분의 호출자는 Database.withConnection 에서 연결을 받아 autocommit 이 켜져 있지만, 같은 메서드가 withTransaction 안에서도 불리므로 블록은 둘 다 다뤄야 한다: 트랜잭션이 없으면 열고, 이미 있으면 savepoint 를 잡는다.

중첩된 경우가 테이블마다 달랐다. Page 는 savepoint 를 잡았고, User 와 UserMerge 는 블록을 맨몸으로 돌리다 예외를 그대로 흘려보냈다 — 블록의 부분 쓰기가 걸린 채로. 셋 다 rethrow 하므로 전부 롤백하는 바깥 핸들러에게는 차이가 없었다 — 잡고 계속 가는 바깥 핸들러에게는 있었다. 남긴 것은 savepoint 판이다. 호출자가 서술할 수 있는 상태로 연결을 남기는 것이 그것뿐이라서다.

2. 로그 테이블은 자기 행을 만료시킨다

AccessLog·IpDeny·UserViewHistory 는 각자 Retention 값을 선언하고, 그 기간이어야 하는 이유를 바로 옆에 적어 둔다. 스케줄된 잡(ApplicationLifecycleHook 의 deleteExpired, 현재 10~30분 무작위 간격)이 각자의 deleteExpired 를 부르고, 시작할 때 세 기간을 로그로 찍는다.

보존 기간을 다른 곳에 다시 적지 말 것. 스케줄러가 "한눈에 보이라고" 셋의 사본을 들고 있던 적이 있다. 두 번 적힌 값은 한 번만 고쳐질 값이다.

삭제 자체는 ExpiredRows.deleteInsertedBefore 가 들고 있다. 세 테이블이 제각각 풀어 쓰던 것이다. 가장 오래된 limit 개 행만 보고 그중 만료된 가장 새 행 앞까지 지우므로(seq < 라 그 행 자체는 다음 실행에 남는다), 기간을 줄여도 거대한 삭제 한 방이 되지 않는다 — 수렴에는 몇 번의 실행이 걸린다. 기준 시각은 이제 데이터베이스의 DATE_ADD(NOW(), ...) 가 아니라 애플리케이션이 계산한다. 두 시계 모두 KST 라 경계는 시계 오차 안에서 같다.

3. datetime 컬럼은 KST 를 담는다

이 저장소의 모든 datetime 컬럼은 한국 표준시(KST, UTC+9)를 담는다.

datetime 은 타임존을 나르지 않으므로, 무엇이 담기는지는 오로지 관례가 정한다. 적어 두지 않으면 읽는 사람이 알아낼 방법이 없다 — 이 절이 있는 이유다.

3.1. 증거 (2026-08-04 실측)

RDS 의 time_zone 이 Asia/Seoul 이라 current_timestamp() 는 KST 를 낸다. Play 의 JDBC 연결도 세션 타임존을 바꾸지 않으므로 애플리케이션이 쓰는 값도 KST 다.

검사는 MAX(value) 와 NOW() 의 차였다. 0이면 KST, 9면 UTC 다.

컬럼

쓰는 쪽

NOW() 와의 차

판정

AccessLog.dateInserted

DB default

0.0h

KST

PageMeta.dateUpdated

애플리케이션

0.0h

KST

DB default 가 채우는 컬럼(23개)과 애플리케이션이 직접 쓰는 컬럼(7개)이 둘 다 KST 다. 섞여 있지 않다.

3.2. 다시 확인하기

NOW() 대비 MAX(value) 는 최근 몇 시간 안에 무언가 쓴 컬럼에서만 답한다. 재는 것은 행의 나이에, UTC 라면 9를 더한 값이라, 조용한 스키마에서는 나이가 9를 삼킨다 — dev 스키마에 돌리면 모든 컬럼이 스무 시간에서 천칠백 시간 사이 어딘가를 읽고, 그것은 아무 말도 아니다.

대신 한 행 안에서 두 메커니즘을 비교한다. PageMeta 는 dateInserted 를 DB default 로, dateUpdated 를 애플리케이션(LocalDateTime.now() 를 upsert 에 넘긴다)으로 쓴다. 두 관례가 갈라진 적이 있다면 모든 행이 9시간 이상 벌어져 있을 것이다:

SELECT COUNT(*), MIN(TIMESTAMPDIFF(MINUTE, dateInserted, dateUpdated))
FROM PageMeta WHERE dateUpdated IS NOT NULL;

최솟값만 의미가 있다 — 편집은 삽입 몇 시간 뒤에 정당하게 일어나므로 최댓값은 크고 아무 말도 아니다. 최솟값이 0 근처면 두 시계가 같다.

이 문서는 2026-09-10 까지 이 검사를 Attachment 의 dateInserted 대 dateUploaded 로 적고 있었고, 2026-08-17 에 운영 44행·dev 43행 전부 0 이라는 결과까지 남겼다. 그런데 dateUploaded 도 markUploaded 가 NOW() 로 쓰는 값이라 그 비교는 DB 시계끼리의 비교였다 — 0 이 나오는 것이 당연했고, 애플리케이션 시계에 대해서는 아무 말도 아니었다. 그날 함께 본 운영 AccessLog 의 NOW() 대비 0.0h 는 유효하다(그 컬럼은 DB default 이고 최근 행이 있었다).

3.3. 규칙

장기적으로 UTC 로 옮기는 구상이 ToDo-Timezone-KST-To-UTC에 있다. 그 이관이 실제로 실행되기 전까지는 아래 규칙이 유효하다 — 계획이 있다는 것이 지금 섞어도 된다는 뜻이 아니다.

  • 새 datetime 컬럼도 KST 다. 섞는 순간 더는 가려낼 방법이 없다.
  • 애플리케이션이 시각을 쓸 때 UTC 로 바꾸지 말 것. current_timestamp() 든 Scala 쪽 LocalDateTime.now() 든, 서버 로컬(= KST)이 거기 들어갈 값이다.
  • 연결의 세션 타임존을 바꾸지 말 것. 바꾸면 current_timestamp() default 가 조용히 다른 시각을 쓰기 시작해, 바꾸기 전과 후의 행이 9시간 벌어진 채 섞인다.

3.4. 다른 저장소는 다르다 — 짐작하지 말 것

같은 RDS 인스턴스의 스키마들이 서로 다른 관례를 따른다(2026-08-04 실측).

스키마

담긴 시각

이 저장소의 스키마

KST

형제 스키마 하나

UTC (전 컬럼, 2026-08-04 통일)

다른 형제 스키마

혼재 — 한 행 안에서 9시간 벌어짐. UTC 로 통일 예정

여러 스키마를 데이터베이스 브라우저에서 같이 볼 때 특히 조심할 것. 뷰어의 전역 "timezone +9h" 설정은 이 저장소의 값을 9시간짜리 거짓말로 바꾼다.

4. 테이블 collation 은 utf8mb4_bin — 적어 줘야 그렇게 된다

데이터베이스의 기본 collation 은 utf8mb4_unicode_ci 인데(운영·개발 둘 다, 2026-09-02 실측) 테이블은 전부 utf8mb4_bin 이다. 그래서 CREATE TABLE 에 테이블 collation 을 안 적으면 다른 테이블과 다른 것을 받는다 — 기본값이 관례가 아니다.

evolution 68 이 그렇게 됐다. UserNicknameChangeRequest 가 운영에서 utf8mb4_unicode_ci 로 만들어져 25개 중 혼자 달랐고, 운영의 SHOW CREATE TABLE 을 커밋된 schema/schema.sql 과 대조하다가 드러났다. 배포 후 헬스체크와 play_evolutions 는 둘 다 "성공" 이라고 말했다 — 그 둘은 문장이 실행됐다는 것이지 의도한 결과가 나왔다는 것이 아니다. evolution 69 가 bin 으로 되돌린다.

  • 새 테이블은 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 으로 끝낸다. EvolutionConventionSpec 이 69 이후의 evolution 마다 테이블 옵션에 COLLATE 가 있는지 확인한다 — 어느 collation 인지는 보지 않는다 — 68 을 그대로 읽어 "이건 걸린다" 를 못박아 둔 채로.
  • 컬럼 하나가 다른 collation 을 써야 하면 그 컬럼에 적는다. UserNicknameChangeRequest.requestedNickname 이 User.nickname 과 같은 utf8mb4_general_ci 인 것이 그 예다 — 대소문자를 무시하는 비교가 그 컬럼의 요구라서, 테이블 기본값과 다른 것이 의도다.
  • 왜 bin 인지는 이 페이지에 근거가 남아 있지 않다. 24개가 이미 그렇고, 하나만 다르면 = 비교의 대소문자 규칙이 테이블마다 갈린다는 것으로 지금은 충분하다.

5. evolution 주석에 세미콜론을 쓰지 말 것

Play 는 evolution 파일을 DB 에 넘기기 전에 모든 ; 에서 문장을 자르고, 주석이 무엇인지 모른다. # 줄 안의 ; 는 거기서 문장을 끝내고, 그 다음 줄부터는 # 이 없는 산문이 SQL 로 실행된다.

2026-09-02 evolution 69 의 첫 배포가 그렇게 실패했다 — "onto bin; requestedNickname is then put back…" 이라는 주석 한 줄 때문에 requestedNickname is then put back to … 가 MariaDB 에 문장으로 들어갔다. 인스턴스 하나가 "inconsistent state" 로 못 떴고, 다른 하나가 옛 릴리스로 버텨 사이트는 멀쩡했다. play_evolutions 의 실패 행은 옛 릴리스도 막으므로, 롤백은 current 만 되돌리는 것으로 끝나지 않고 그 행을 지우는 것이 먼저다(문장이 하나도 실행되지 않은 것을 SHOW CREATE TABLE 로 확인한 뒤).

68 의 주석이 통과한 것은 거기 ; 가 없었기 때문이지 주석이 안전해서가 아니다. EvolutionConventionSpec 이 이제 모든 evolution 의 주석 줄에서 ; 를 찾아 실패한다.

6. See Also

6.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 38.56% ToDo-Timezone-KST-To-UTC date(13:29), kst(14:10), db(8:11), utc(7:12), datetime(5:14), time(3:14), inserted(5:8), timestamp(3:10), updated(4:7), 있다(4:6)

6.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+